<?php
namespace phiction;
use \phiction\string_utils as str;
use \phiction\iterator_utils as iter;

// sugar_spec = sugared specification
// full_spec = full specification
// spec = full_spec

class html
{
    private $strip;
    private $pretty;

    function __construct(config $config)
    {
        $this->strip = !$config->debugging;
        $this->pretty = $config->pretty;
    }

    function render($sugar_spec): string
    {
        $spec = self::desugar($sugar_spec);
        $tag = $spec['tag'];

        switch ($tag) {
            case '>': return $this->render_text($spec);
            case '#': return $this->render_comment($spec);
            case '.': return $this->render_concat($spec);
            default: return $this->render_element($spec);
        }
    }

    // returns sugared spec
    function mark($sugar_spec)
    {
        if ($this->strip) return $sugar_spec;
        $trace = (new \Exception)->getTrace()[0];
        $trace = sprintf('%s (%d)', $trace['file'], $trace['line']);
        return ['.', ['#', $trace], $sugar_spec];
    }

    // convert sugared spec into full spec to ease further processing
    static private function desugar($sugar_spec)
    {
        # desugar string notation into sugared spec
        if (is_string($sugar_spec)) $sugar_spec = ['>', $sugar_spec];
        if (!is_array($sugar_spec)) throw new \InvalidArgumentException("sugared spec can only be an array or string.");

        # desugar sugared spec into full spec
        $attrs = iterator_to_array(iter::strip_null(iter::str_keyed($sugar_spec)));
        $childs = iterator_to_array(iter::detach(iter::strip_null(iter::int_keyed($sugar_spec)), $tag), false);
        if (is_null($tag)) throw new \InvalidArgumentException('spec should have a tag.');

        # escape attrs values
        foreach ($attrs as $attr => &$value)
            if (is_string($value))
                $value = str_replace(['&', '"'], ['&amp;', '&quot;'], $value);

        return [
            'tag' => $tag,
            'attrs' => $attrs,
            'childs' => $childs,
        ];
    }

    // example:
    //   "hello" (no padding) => "hello"
    //   "hello" (padded) => " hello "
    //   "\nhello\n\n" => "hello"
    //   "hello\nworld" => "\n    hello\n    world\n"
    //   "\nhello\nworld\n\n" => "\n    hello\n    world\n"
    // rule:
    //   trim newline
    //   return as is if only one line
    //   indent
    //   surround by newlines
    //   return
    static private function format_childs(string $comment, bool $pad=false): string
    {
        $comment = trim($comment, "\n");
        if (str::single_lined($comment)) return $pad ? " $comment " : $comment;
        return "\n" . str::indent($comment) . "\n";
    }

    private function render_text(array $spec): string
    {
        $attrs = $spec['attrs'];
        $childs = $spec['childs'];

        if (count($attrs) !== 0) throw new \InvalidArgumentException("text spec cannot have attributes");
        if (count($childs) !== 1) throw new \InvalidArgumentException("text spec must have exactly 1 child");
        if (!is_string($childs[0])) throw new \InvalidArgumentException("text spec's child must be a string");

        return htmlspecialchars($childs[0], ENT_COMPAT | ENT_HTML5);
    }

    private function render_comment(array $spec): string
    {
        if ($this->strip) return '';

        $attrs = $spec['attrs'];
        $childs = $spec['childs'];

        if (count($attrs) !== 0) throw new \InvalidArgumentException('comment spec cannot have attributes');
        if (count($childs) !== 1) throw new \InvalidArgumentException('comment spec must have exactly 1 child');
        if (!is_string($childs[0])) throw new \InvalidArgumentException("comment spec's child must be a string");

        if ($this->pretty) {
            $comment = self::format_childs($childs[0], true);
            return "<!--$comment-->\n";
        } else {
            return "<!-- $childs[0] -->";
        }
    }

    private function render_concat(array $spec): string
    {
        $attrs = $spec['attrs'];
        $childs = $spec['childs'];

        if (count($attrs) !== 0) throw new \InvalidArgumentException('concat spec cannot have attributes');

        return implode(array_map(function($child) {
            return $this->render($child);
        }, $childs));
    }

    private function render_element(array $spec): string
    {
        $tag = $spec['tag'];
        $attrs = $spec['attrs'];
        $childs = $spec['childs'];
        $single = false;

        if ($tag === '') throw new \InvalidArgumentException("tag name cannot be empty");
        if ($tag[0] === '/') {
            $single = true;
            $tag = substr($tag, 1);
            if (count($childs) !== 0) throw new \LogicException("single tag cannot have childs: $tag");
        }
        if (preg_match('#[^a-z0-9]#', $tag)) throw new \InvalidArgumentException("invalid tag name: $tag");

        $attrs = iter::implode(iter::map_kv(function ($attr, $value) {
            if ($value === false) return '';
            if ($value === true) return " $attr";
            return sprintf(' %s="%s"', $attr, $value);
        }, $attrs));

        $childs = implode(array_map(function($child) {
            return $this->render($child);
        }, $childs));

        if ($single) {
            if ($this->pretty) {
                return "<$tag$attrs>\n";
            } else {
                return "<$tag$attrs>";
            }
        }
        else {
            $doctype = $tag === 'html' ? "<!doctype html>\n" : "";  // doctype is always on its own line
            if ($this->pretty) {
                $childs = self::format_childs($childs);
                return "$doctype<$tag$attrs>$childs</$tag>\n";
            } else {
                return "$doctype<$tag$attrs>$childs</$tag>";
            }
        }
    }
}

